fix(runtime,mcp,service-datasource): the #6504 consumer sweep — three list consumers stop claiming what a known-partial read cannot support - #8854
Conversation
… list consumers stop claiming what a known-partial read cannot support (#6504) PR #7721 landed `IMetadataService.listDiagnosed?(type)`; this is the consumer half. Every `list()`/`listObjects()` consumer in metadata-protocol, rest, runtime and the plugins was qualified individually per PR #6051's discipline; most publish a snapshot with no count and are correct unchanged. Three make a claim a short read makes false: - `removeDatasource` spent the bound-object count as the sole guard over an irreversible delete (which also unbinds the datasource's secret). An outage under-counts, and `0` reads as "nothing is bound", so the guard OPENED. It now refuses with SERVICE_UNAVAILABLE/503 and leaves record, secret and pool intact. - the MCP `list_objects` tool published `totalCount` — the same claim PR #7721 removed from the `objectstack://objects` resource, on the other primitive. A degraded read now omits the key and serves `partial`/`returnedCount`/ `warning` plus the 503 envelope, on both bridges (stdio + HTTP). - the ADR-0015 boot gate announced "all federated objects match" over whatever it could enumerate. It now warns that the sweep was incomplete and names what it validated. It does NOT abort boot on a degraded read. Both new host-wiring members are optional exactly as `listDiagnosed` is. `packages/spec` is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…e ablation measures the decision, not the wiring (#6504) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
…ts for the bound-count pin (#6504) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeQRiAa7vYRVX5Fog7Zby8
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 27 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
…commits that decided them (objectstack-ai#20609) Part of objectstack-ai#20596 Clause-②: no ## What changed This is the first stage of the `domain:services` lane of the dead-citation sweep. It covers `packages/services/service-messaging/src/**` and nothing else, the largest package in the lane that no open PR or in-flight claim holds (the claim, `5884863234`, gives the order). Later stages cover the other packages, so this PR says `Part of` and the card stays open. Every comment or docblock site in scope that cited a tracker number answering 404 has been rewritten in ruling C+D's form C (comment 5749154545 on objectstack-ai#19123), the way the landed `packages/spec/src` stages apply it (PR objectstack-ai#20533 is the method). That is **127 sites on 109 lines in 28 files, covering 14 numbers**: the 97 census sites outside the generated headers, and 30 sites in test comments, which the census defers. Each rewritten line now cites the commit in `origin/main` history that decided what the line describes, and it says in its own words what that commit decided. No ADR or ruling-record file in `docs/adr/` or `scripts/adr-anchors/` records the decision behind any of the 14 numbers, so every anchor is a commit: **13 distinct shas**. No number was dropped. Only comments changed. Every touched source file keeps its line count (116 lines out, 116 in, over 28 files), so no line citation into these files moves. Seven of those 116 lines held no dead citation: they are the other half of a sentence that had to be reflowed (`inbox-caller.ts:87`, `:88`, `messaging-service.test.ts:972`, `notification-keyed-text-bounds.test.ts:83`, `notification-subscription.object.ts:81`, `:82`), or a pointer that lost its referent (`sql-outbox.ts:281`, 「the race the card describes」 to 「the race that commit describes」, because line 278 now names the commit). No code token moves (see the guard below). **No citation number is added.** Every tracker number on an added line was already on the line it replaces (added-minus-removed over the whole diff: 0). No PR number stands on an added line. Fifteen dead sites are left on purpose: 12 string literals and 3 generated file headers (see the list below). One more file: a `patch` changeset for `@objectstack/service-messaging`, because the rewritten docblocks ship (see Changeset below). ## Census: `service-messaging`, before and after **Instrument (A1).** The gate's own `node scripts/check-issue-citations.mjs --census --json`, read-only, unchanged. Its surface is comment prose in `packages/**/src/**/*.ts` with string literals blanked, and it defers `*.test.ts`. The count below is its `allocated-but-absent` findings under `packages/services/service-messaging/`. | reading | tree | board | whole-repo `allocated-but-absent` | service-messaging sites | lines | files | numbers | |---|---|---|---|---|---|---|---| | before | base `7a1faf1a5`, run 2026-09-29T06:31:54Z to 06:35:25Z | enumerated, 185 pages, frontier objectstack-ai#20606, 18,433 numbers | 2,457 | **100** | 82 | 22 | 13 | | after | head `685200760`, run 06:48:33Z to 06:52:17Z | enumerated, 185 pages, frontier objectstack-ai#20606, 18,433 numbers | 2,360 | **3** | 3 | 3 | 1 | The before count matches the 100 that census `5884031174` read at `f11b5f20`. The whole-repo drop is 97, exactly this diff's census sites, and the `resolves` tally is 32,744 in both runs. The 3 left are the generated headers below. `267c11562`, the final head, adds only the changeset, which is outside the census surface. **Supplementary instrument, the whole scope.** The census does not read test files or strings, and this stage's scope includes both. So a second reading runs the gate's own exported `extractCitations` (whole-file and comment-prose projections) and `classifyCitation` over every `.ts` file under `service-messaging/src` (87 files), against the same enumerated board. | reading | citations | dead | src comment | test comment | src string | test string | |---|---|---|---|---|---|---| | before, `7a1faf1a5` | 613 | **142** | 100 | 30 | 3 | 9 | | after, `685200760` | 486 | **15** | 3 | 0 | 3 | 9 | Its src-comment column equals the census's 100, which is the control on the second instrument. The 450 resolving citations and 21 pull-request citations are the same in both readings. ## Per-number table Sites and files are all dead sites in scope at the base (comments and strings, tests included). `rewritten / left` counts comment sites rewritten and sites left. Every anchor was read in its diff or message, not only in its subject: it is the commit that made the change the line describes, and its own diff or message names the number it replaces. | number | sites / files | rewritten / left | anchor: what it decided | |---|---|---|---| | `objectstack-ai#6206` | 1/1 | 1/0 | `8e13ca876`: the share-link routes pass the whole authz envelope into enforcement instead of a four-field trim. The line lists it as one member of the defect family behind `assembleExecutionContext` | | `objectstack-ai#6363` | 14/2 | 13/1 | `17d095413`: `listInbox`'s `unreadCount` counts the total unread, not the fetched window (maintainer ruling 2026-08-07, Option A: make the declaration true); it adds `countUnreadTotal`. The same anchor the spec stages gave this number | | `objectstack-ai#9722` | 1/1 | 1/0 | `2074b2651`: corrects the `sys_notification_subscription` index note — `role:` and `team:` resolve against `sys_member` and `sys_team_member` | | `objectstack-ai#9807` | 4/3 | 4/0 | `44738f7af`: marks the subscription-to-recipient expansion NOT WIRED and aligns `principal` with the forms `RecipientResolver.resolveOne()` accepts, email kept verbatim | | `objectstack-ai#11374` | 17/6 | 16/1 | route A of the maintainer's 2026-08-24 ruling: a keyed text column declares a `maxLength` sourced from its producer. Written as 「route A, ruling 2026-08-24」 beside `e4902d2b9`, the commit that applied it here. `scripts/check-keyed-text-bounds.mjs`'s header states route A in words | | `objectstack-ai#11452` | 6/3 | 5/1 | `3b5f0360c`: the plugin-facing `listInboxAsCaller`, scoped to the authenticated caller | | `objectstack-ai#11453` | 26/13 | 23/3 | `1a47a5368`: `ack()` refuses a row that is not `in_flight` (`NotificationAckError`, `DELIVERY_NOT_ELIGIBLE`), as a compare-and-set in the SQL outbox. The same anchor stage 2 gave it | | `objectstack-ai#11671` | 4/4 | 1/3 | `09b4f4e4e`: `os i18n extract --source-hashes` writes the per-locale provenance companion (maintainer ruling objectstack-ai#12069 Option A, which stays cited) | | `objectstack-ai#11741` | 6/2 | 5/1 | `b706af987`: `SendEmailInput` gains `organizationId`, and the email channel threads it on both arms. The same anchor stage 1 gave it | | `objectstack-ai#11859` | 29/13 | 27/2 | `d9cf78eaa`: `ack()` takes the claimed record back and binds its claim credential in the compare-and-set. The same anchor stage 2 gave it | | `objectstack-ai#12144` | 2/2 | 2/0 | `3a04b0125`: identifier ceilings are storage-owned (`sys_metadata.name` is 255) | | `objectstack-ai#12147` | 1/1 | 1/0 | `945e91a13`: the class-level `check-keyed-text-bounds` gate | | `objectstack-ai#12978` | 17/6 | 16/1 | `e4902d2b9`: declares the sourced `maxLength` on all 15 keyed text columns of the `sys_notification_*` objects. No commit message names the card; its diff is where every `[objectstack-ai#12978]` marker entered the tree | | `objectstack-ai#18424` | 14/4 | 12/2 | `879b51270`: an email or SMS channel with no transport refuses with `transport_not_configured` instead of reporting success | Every cited sha matches exactly one commit (`git rev-parse --disambiguate`, count 1 for each), and every one is an ancestor of the base (`merge-base --is-ancestor`, exit 0 for all 13). The history was unshallowed first (`git fetch --unshallow`, 15,062 commits), so no anchor was read from a truncated log. Wordings to check, each true of its commit: - `inbox-caller.ts:86-88`: 「(objectstack-ai#6071, objectstack-ai#6551, and the share-link envelope trim commit 8e13ca8 undid)」. `8e13ca876`'s message records the trim (four fields kept, five dropped) and the whole-envelope fix. - `outbox.ts:72`: 「the option-A shape commit d9cf78e's ruling refused」. `d9cf78eaa`'s message: 「The caller never supplies an identity: ownership is proven by round-tripping what claim() returned.」 - The fifteen `sys_notification_*` bound comments: `[commit e4902d2] ... (route A, ruling 2026-08-24)`. `e4902d2b9`'s message opens 「Every bound names its producer in the declaration」, and `3954fb7df`'s records the ruling's date and its A and C routes. - `notification-keyed-text-bounds.test.ts:82-83`: the `objectstack-ai#9807` pointer becomes 「Every other arm of the grammar commit 44738f7 documented is narrower」, because `44738f7af` is where the email arm of the selector grammar was written down. - `outbox-ack-claim-ownership.integration.test.ts:40`: 「the objectstack-ai#11453 file beside this one」 names the file itself, `outbox-ack-precondition.integration.test.ts`. ## The 15 sites left - **Non-test strings (3 sites, 2 lines), refusal text.** `outbox.ts:187` (「see objectstack-ai#11453」) and `outbox.ts:210` (「(objectstack-ai#11453, objectstack-ai#11859)」) are inside `notificationAckNotClaimedMessage` and `notificationAckLostClaimMessage`, the messages `NotificationAckError` carries. They are runtime strings, so they are form D, not form C. The landed objectstack-ai#20234 stages left every string site as a token and rewrote no refusal text, so these are left and listed, as PR objectstack-ai#20533 did. The form D stages that did rewrite strings (objectstack-ai#20233's) cover `os migrate meta` guidance, a different class. - **Test titles (9 sites, 8 lines).** `describe` titles in `email-channel.test.ts:90`, `:592`, `messaging-service.test.ts:809`, `:1286`, `notification-keyed-text-bounds.test.ts:38` (2 numbers), `outbox-ack-claim-ownership.integration.test.ts:109`, `outbox-ack-precondition.integration.test.ts:110` and `sms-channel.test.ts:90`. Tokens, left as they were. - **Generated headers (3 sites).** Line 8 of `es-ES`, `ja-JP` and `zh-CN` `.source-hashes.generated.ts` reads 「(objectstack-ai#11671, maintainer ruling objectstack-ai#12069 Option A, extending objectstack-ai#8765 Option B)」. `os i18n extract` writes that line from `packages/cli/src/utils/i18n-extract.ts:2294`, and 27 generated files across the repo carry it. A hand edit here would be undone by the next extract, so the fix belongs at the producer in a later stage, which regenerates every copy. The hand-written `translations/index.ts:29` is rewritten here. ## Mechanical guard: no code token moves The check compares leaf tokens with comments stripped, base `7a1faf1a5` against head. It uses the TypeScript parser's leaf nodes, so template literals are read in context, and it excludes JSDoc nodes. It ran over all 28 touched `.ts` files. - Real run: 52,337 base tokens, **0 files with a token change** (exit 0). - Comment-insertion control, in `outbox.ts`: 0 files changed, as expected (exit 0). - Positive control, a declaration inserted into `outbox.ts`: DIFFER (exit 1). The first attempt was a no-op: its anchor text was still inside the replacement, so `scripts/ablation-replace.mjs` refused it before the guard ran. It was redone with a hitting anchor. - Positive control, one digit changed inside the kept `outbox.ts:210` refusal string: DIFFER (exit 1). Every mutation went through `scripts/ablation-replace.mjs`, and each restore was proven byte-identical to the HEAD blob (`80618f8711e2`) with `git diff HEAD` empty. ## Changeset This change ships bytes, so a `patch` changeset for `@objectstack/service-messaging` is included. It says only that the provenance comments were re-anchored. Measured on the built package (A3): `files[]` is `dist`, `README.md` and `CHANGELOG.md`. After `pnpm --filter @objectstack/service-messaging build`, the rewritten comments reach both halves of `dist`. `d9cf78eaa` appears 8 times in `dist/index.d.ts`, `1a47a5368` 6 times and `17d095413` 6 times, and `e4902d2b9` appears 15 times in `dist/index.js`. The positive control, an unchanged `notification-subscription.object.ts` docblock sentence, appears in `dist/index.d.ts`, and a negative control phrase appears nowhere. The only dead numbers left in `dist` are the two kept refusal strings. ## Gates (head `267c11562`) - **Citation judging, as CI runs it:** `pnpm check:issue-citations` (self-test, 114 cases in 8 batteries) exits 0, and `node scripts/check-issue-citations.mjs` exits 0. The diff-scoped run judged 5 citations across 19 files, and all 5 resolve. - **Doc authoring:** `pnpm check:doc-authoring` exits 0. - **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `267c11562` derived 64 families. They include all 50 derived at dispatch, plus 14 more. All 64 exit 0. `--ran` reports 64 run, 0 NOT MEASURED, 0 unrun, and exits 0. - Three gates first exited 3 (PREREQUISITE NOT MET) because the workspace was unbuilt: `check:dual-build-cjs-loads`, `check:i18n` and `check:type-check-debt`. A full `turbo run build` of `./packages/*` and `./packages/*/*` then ran under the shared verify lock (71 tasks, exit 0). The first two exited 0 on their rerun. - `check:type-check-debt` exited 3 once more: `outbox.ts`'s mtime had moved during the guard controls, although its bytes had not, so turbo's cache hit left `dist` older than the source. A direct `pnpm --filter @objectstack/service-messaging build` then let it exit 0 (4 ledger entries re-measured, none above its number). - **Tests and typecheck:** - `pnpm --filter @objectstack/service-messaging test`: 46 files and 507 tests pass, covering every touched test file. - `pnpm --filter @objectstack/service-messaging typecheck` exits 0. Its `tsc` program lists all 46 test files and 87 files under `src/` in total (`--listFiles`). - **Lint, as a proven narrowing:** `eslint --no-inline-config --format json` over the 28 touched `.ts` files gives 28 files, 0 errors and 0 warnings. All 28 are in eslint's own population (`isPathIgnored` is false for each). `eslint.config.mjs` never enables type-aware linting (no `parserOptions.project`, as its own line 328 states), so a comment edit here cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is CI's run. - **Control bytes:** `pnpm check:nul-bytes` exits 0, and a raw scan of the 28 files for control bytes finds none. ## Acceptance notes - **The census instrument returned a truncated board once, at exit 0.** The first `--census --json` run of this stage (06:25:55Z, base `7a1faf1a5`) read `enumerated (85 pages)`, frontier objectstack-ai#8854, 8,444 numbers, when the newest number was above objectstack-ai#20600. The `Link` header of its 85th page had carried no `rel="next"`, so `enumerateBoard` stopped and classified 16,187 citations as `never-issued`. A reader counting only `allocated-but-absent`, as this stage's count does, would have got 8 service-messaging sites instead of 100, silently. The next four enumerations in this session read 185 pages and frontier objectstack-ai#20606, and the counts above come from those. Nothing in `enumerateBoard` compares its frontier with the newest issue number, which `probeBoard` does read. Reported to the seat, not changed here: this stage makes no instrument change. - **What stays for later stages.** - The 3 generated `objectstack-ai#11671` headers, whose producer is `packages/cli/src/utils/i18n-extract.ts:2294`. That line is the repo-wide carrier (27 generated files). - The 3 refusal-string sites in `outbox.ts` (form D) and the 9 test-title sites. - **Base.** The branch is 9 commits behind `origin/main` (`0f6dcac5e`, read at 07:23Z). One of them, `8c87d26a5` (the version packages release), touches `service-messaging`, but only its `CHANGELOG.md` and `package.json`, and neither is in this diff. So there was no merge. - **Anchors shared with the spec stages.** `17d095413` (objectstack-ai#6363), `1a47a5368` (objectstack-ai#11453), `d9cf78eaa` (objectstack-ai#11859) and `b706af987` (objectstack-ai#11741) are the anchors stages 1 and 2 already gave the same numbers in `packages/spec/src`, so each number carries one anchor across the tree. --- _Generated by [Claude Code](https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #6504
The consumer half of #6504. PR #7721 landed
IMetadataService.listDiagnosed?(type)and the two measuredpackages/mcpconsumers; this sweeps the rest —metadata-protocol,rest,runtimeand the plugins — and leavespackages/specuntouched.The whole card is the discipline, so here is the inventory first
Every metadata-service list-family call site in the sweep's scope, qualified individually per PR #6051. Fifteen of eighteen are correct unchanged, and saying why is the deliverable, not a preamble: a caller publishing a snapshot with no count has nothing to mis-state, and "upgrade them all to
listDiagnosed" would have been the wrong answer.service-datasourcecountBoundObjects⇒removeDatasourceruntimebuildMcpBridge.listObjects⇒list_objectstooltotalCountruntimeexternal-validation-plugin.runValidationmcpstdio-data-bridge.listObjects⇒ same toolmetadata-protocolprotocol.tsgetMetaItemsruntime merge{ type, items }runtimedomains/meta.tsdispatcherGET /metadata/:typefallbackruntimedomains/mcp.tsskill listingruntimeaction-execution.collectActionDeclarationsruntimeexternal-validation-plugin.scheduleDriftChecksplugin-securitypermission-evaluator.resolvePermissionSetsbootstrap-declared-*(permissions, capabilities, positions, email templates, webhooks, sharing rules)plugin-hono-server/me/appsadditive fallbackservice-datasourcelistDatasourceRecordsservice-datasourceplugin.tslistObjectswiringpackages/resthas no direct consumer at all. It reads metadata throughprotocol.getMetaItemsand throughpackageService, never throughIMetadataService's list family — verified by grep overpackages/rest/src. That is a finding of the sweep, not an omission from it.Three notes on the ones left alone, because each was a real decision:
protocol.tscall site the dispatch named.getMetaItemsanswers{ type, items }and publishes no count, so by the card's own discriminator it is not a mis-describer. Its declared response typeGetMetaItemsResponselives inpackages/spec, which this card does not own — so a verdict field there is not available to this PR even if it were wanted. Its singular sibling already refuses an outage as an absence (readItemFromMetadataService+throwMetadataServiceUnavailable, MetadataManager.get() 丢弃 loadDiagnosed 的 degraded 判定:loader 读不到与「这一项没声明」在 6 个消费点上不可分辨 #5840), and the asymmetry is now the only one left in the file. The dispatch expected two call sites here; on today's tree there is one (protocol.ts:5378), the second having been absorbed intomergePackageAwareOverlayby finding: GET /api/v1/meta/skill lists a skill twice after a runtime PUT, and the meta PUT never reaches the prompt bridge (two divergent skill-read paths) #7654/fix(metadata-protocol): key the #3050 authoring gate on authoringChannel so ADR-0090 D11 OWD rules run on host-config deployments #7710. Line numbers were re-derived after mergingmain, as asked.resolvePermissionSetsreadslist('permission')to decide authorization, which is the sharpest-sounding consumer in the sweep — but it is already fail-CLOSED by construction (an unresolvable set grants nothing) with bootstrap and DB fallbacks behind it and a [security][observability] resolvePermissionSets swallows dbLoader failures silently — custom permission sets vanish with no log #2565 warn on failure. A degraded read there costs access, never grants it. Adding a refusal would trade a safe direction for a new outage-driven denial.{ seeded, updated }describes what the bootstrap did, not what the environment contains, and each reads the registry first with the metadata listing only as a fallback. None prunes rows absent from the list, so a short read cannot delete anything — the failure mode that would have made them urgent.What each changed consumer now does, and why
1. A datasource removal no longer deletes on a count it could not take
This is the sharpest consumer in the card and the reason the sweep grades above polish.
removeDatasource's guard is the only thing in front of an irreversible operation that also unbinds the datasource's secret:During a loader outage the object listing goes silently short, and the worst value is the benign-looking one:
0reads exactly as nothing is bound. So the guard did not merely mis-state — it opened, and the datasource was deleted while objects were still bound to it.It now prefers the optional
countBoundObjectsDiagnosedand refuses on a degraded read, carrying the ADR-0112 envelope:SERVICE_UNAVAILABLE/ 503, not this service's generic 400 — nothing about the request is wrong, the condition is a dependency outage that may clear, and the caller should retry.admin-routes.tsrelays a thrown 503 envelope instead of flattening it tobadRequest; the relay reads the error rather than special-casing the route, and requires bothstatusandcodeso an unrelated error carrying a straystatuscannot re-route itself. The loader detail rides oncause, never in the served message.Withholding the destructive act is the plural analogue of withholding
totalCount: here the "answer" is the deletion, so the only honest response is to make none. It is fully reversible in a way the deletion is not.2 + 4. The
list_objectsMCP tool stops publishingtotalCounton a known-partial listingPR #7721 removed exactly this claim from
objectstack://objects— the resource. Thelist_objectstool renders the same{ objects, totalCount }from the same listing and was never covered, because the resource is served offIMetadataServicedirectly while the tool goes through the injectedMcpDataBridge. A client asking how many objects does this app have therefore got an honest answer over one primitive and a confident wrong integer over the other.Same fix, same words:
{ objects, totalCount }, byte-identical to before. A count from a complete read is a fact this tool was always right to state.objects,totalCountabsent, andpartial/returnedCount/warning/code/status: 503in its place. A client readingtotalCountgetsundefined— which fails, or renders as nothing — where a plausible integer would have been believed.returnedCountcounts what is served, i.e. after the system-object filter, since naming the pre-filter number would restate the same over-claim one field along.Both bridges implement the new optional member — stdio (
packages/mcp) and HTTP (packages/runtime) — because a completeness claim must not depend on which transport a client connected over. The sentence and the 503 code moved to a sharedmetadata-completeness.tsrather than being copied: two surfaces answering one question must say it in one vocabulary, andmcp-server-runtime.tsimportsmcp-http-tools.ts, so exporting from either would be a cycle.3. The ADR-0015 boot gate stops announcing an all-clear over a sweep it could not complete
validateAll()sweepslistObjects(), filters to the federated objects, and the gate then logged all federated objects match their remote schema, with a count. Federated objects held by an unreadable loader were never validated, soonMismatch: 'fail'could not have fired for them — an outage silently narrows the gate and then announces a clean sweep.Only the claim changes. The gate still validates and still refuses on every mismatch it found; on a degraded read it warns that the swept set was incomplete and names what it did validate, as a count of what was validated rather than a total.
⛔ It deliberately does not abort boot on a degraded metadata read. Turning a transient dependency outage into a refusal to start is a new failure mode bought with a diagnosis fix — the opposite of what this card is.
The verdict is asked of the metadata service directly rather than threaded through
SchemaValidationReport. The plain reason: that report is declared inpackages/spec, which this card does not own. The better reason: the question is about the object listing and the metadata service is where the answer lives, so routing it through a second contract would add a member every implementer must remember to fill in, to relay a fact the authority can already be asked for.Optionality, everywhere, for one reason
countBoundObjectsDiagnosedandlistObjectsDiagnosedare both optional, stacked onIMetadataService.listDiagnosed's own optionality: a host whose metadata service predates the verdict behaves exactly as it did before, and a service without it reports nothing degraded — precisely what it could express. Every changed consumer has a pin for that direction, and a second pin that absence does not become a false alarm either.Every consumer composes the verdict the same way PR #7721 established: the items come from the resolver the call site already used, and only the verdict is asked of
listDiagnosed.listObjectsis its own member ofIMetadataServiceand declares no equivalence tolist('object'), so re-resolving items through the diagnosed read would presume one — the private dialect Prime Directive #12 forbids. On the implementation that ships they are the same read and share one cache entry and one single-flight slot, so the probe costs nothing.Verification
The real loader failure is driven in
packages/runtime, which depends on@objectstack/metadata:packages/runtime/src/list-diagnosed-consumer-sweep.test.tsbuilds a realMetadataManagerwhoseDatabaseLoadersits over a driver whosefind()throwsECONNRESET, soreadListUncached()'s owncatchproduces the verdict anddegradedis computed rather than injected. The healthy count is 3, the degraded count is 1, and both directions are asserted on the number itself.Doubles in the other two packages, stated plainly rather than papered over —
packages/mcpandpackages/services/service-datasourcedo not depend on@objectstack/metadata, and adding that dependency for a test would be a larger change than the fix. This is the same split PR #7721 and #6055 both took. Each file's header says so, and names where the real-failure pin lives. What those files pin is what only lives there: what a datasource removal does with an untrustworthy count, and what the tool renders once it holds the verdict. Thepackages/mcppin drives the real MCP HTTP transport (tools/call), not the handler in isolation, so the payload asserted is the one a client receives.Reverse verification — four ablations, direction predicted in each test header before running:
totalCountinlist_objectscountBoundObjectsguard inremoveDatasourcelistObjectsDiagnosedfrombuildMcpBridgeThe failed prediction is the useful one and is recorded in the test header rather than quietly corrected. The extra red was "a complete read that finds bindings still refuses": the harness supplied that case's count only through the diagnosed member, so the ablated build read the plain count as
0and removed the datasource. That is a fixture where the two counts disagree, which measures the wiring instead of the decision — on the shipped wiring they are the same filter over the same listing. The harness now derives the plain count from the diagnosed one, and the re-measurement matched. Ablations were applied with a patch script and reverted withgit checkout HEAD -- pathfrom the committed state; ⛔ nogit stashat any point.The mcp-tool ablation is deliberately the declared-but-unconsumed shape — the interface member and both bridge implementations left in place, only the tool's read removed — because a whole-file revert would also delete the interface member and turn the optionality cases red for the wrong reason.
Suites and gates, all run at
99dfae356(the final commit):pnpm --filter @objectstack/runtime --filter @objectstack/mcp --filter @objectstack/service-datasource test— runtime 161 files / 2425 tests passed, mcp 19 / 200, service-datasource 18 / 421. 3046 passed, 0 failed. Three new files, 23 new cases.typecheckon the same three — allDone.eslinton all 13 changed files — clean, exit 0.node scripts/pm/dispatch-gates.mjsagainst the actual changed paths, all PASS:check:changeset-gate-self-tests,check:cross-package-test-inputs,check:objectui-changeset,check:route-envelope,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check:query-options-erasure,check:type-check-coverage,check:nul-bytes(plus agrep -naPcontrol-character self-scan over every changed file), and the judgment-call familiescheck:error-code-casingandcheck:engine-double-contract— neither applies (the 503 reuses the standard catalog'sSERVICE_UNAVAILABLE; no new fake engine).pnpm check:type-check-debtafter a fullturbo run buildof the workspace — "33 ledger entries re-measured, 1926 raw tsc errors total, none above its recorded number".@objectstack/runtimemeasures exactly its recorded 227 and@objectstack/mcpexactly its recorded 53, so the two new hidden-layer test files contributed zero — no ledger headroom spent ([finding][devx]check:type-check-debt的 ledger 余量会让新写的 pin 变哑:mongodb 曾有 33 条余量吞掉一次真实回退,另有 5 条目前带 4–19 余量 #6376). The one reported surplus is@objectstack/lint(-1), pre-existing and untouched by this PR.service-datasourcecarries no TEST_DEBT entry: its tests are inside its tsc program and typecheck clean.One gate moved that no path derivation names, and it is worth flagging:
packages/mcp/src/mcp-write-response-internal-fields.tripwire.test.tsenumerates every callable face on the stdio bridge at runtime and fails on any it has no recipe for.listObjectsDiagnosedis a new face, so the tripwire went red on first run and is now registered as the read/summary face it is — exactly the "fires on what a diff contains" class the dispatch warned about.Scope
packages/specuntouched —IMetadataService.listDiagnosedalready landed in PR feat(spec,metadata,mcp): let a plural metadata read say it is known-partial (#6504) #7721, and the two new members are on package-local host-wiring interfaces.docs/adr/**, nocontent/docs/releases/.Part of #6504, notFixes: whether the sweep is complete is the PM's call after reading the inventory above, not this PR's to assert.Generated by Claude Code